diff --git a/web/chat/chat-message-list.react.js b/web/chat/chat-message-list.react.js index dd7d49266..2ac190be6 100644 --- a/web/chat/chat-message-list.react.js +++ b/web/chat/chat-message-list.react.js @@ -1,343 +1,343 @@ // @flow import classNames from 'classnames'; import { detect as detectBrowser } from 'detect-browser'; import invariant from 'invariant'; import * as React from 'react'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions.js'; import { registerFetchKey } from 'lib/reducers/loading-reducer.js'; import { type ChatMessageItem, useMessageListData, } from 'lib/selectors/chat-selectors.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { threadIsPending } from 'lib/shared/thread-utils.js'; import type { FetchMessageInfosPayload } from 'lib/types/message-types.js'; import { type ThreadInfo, threadTypes } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import css from './chat-message-list.css'; import { MessageListContext } from './message-list-types.js'; import Message from './message.react.js'; import RelationshipPrompt from './relationship-prompt/relationship-prompt.js'; import { useTooltipContext } from './tooltip-provider.js'; import { type InputState, InputStateContext } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { useTextMessageRulesFunc } from '../markdown/rules.react.js'; import { useSelector } from '../redux/redux-utils.js'; const browser = detectBrowser(); const supportsReverseFlex = !browser || browser.name !== 'firefox' || parseInt(browser.version) >= 81; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +activeChatThreadID: ?string, +messageListData: ?$ReadOnlyArray, +startReached: boolean, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, // withInputState +inputState: ?InputState, +clearTooltip: () => mixed, }; type Snapshot = { +scrollTop: number, +scrollHeight: number, }; class ChatMessageList extends React.PureComponent { container: ?HTMLDivElement; messageContainer: ?HTMLDivElement; loadingFromScroll = false; componentDidMount() { this.scrollToBottom(); } getSnapshotBeforeUpdate(prevProps: Props) { if ( ChatMessageList.hasNewMessage(this.props, prevProps) && this.messageContainer ) { const { scrollTop, scrollHeight } = this.messageContainer; return { scrollTop, scrollHeight }; } return null; } static hasNewMessage(props: Props, prevProps: Props) { const { messageListData } = props; if (!messageListData || messageListData.length === 0) { return false; } const prevMessageListData = prevProps.messageListData; if (!prevMessageListData || prevMessageListData.length === 0) { return true; } return ( ChatMessageList.keyExtractor(prevMessageListData[0]) !== ChatMessageList.keyExtractor(messageListData[0]) ); } componentDidUpdate(prevProps: Props, prevState, snapshot: ?Snapshot) { const { messageListData } = this.props; const prevMessageListData = prevProps.messageListData; if ( this.loadingFromScroll && messageListData && (!prevMessageListData || messageListData.length > prevMessageListData.length || this.props.startReached) ) { this.loadingFromScroll = false; } const { messageContainer } = this; if (messageContainer && prevMessageListData !== messageListData) { this.onScroll(); } // We'll scroll to the bottom if the user was already scrolled to the bottom // before the new message, or if the new message was composed locally const hasNewMessage = ChatMessageList.hasNewMessage(this.props, prevProps); if ( this.props.activeChatThreadID !== prevProps.activeChatThreadID || (hasNewMessage && messageListData && messageListData[0].itemType === 'message' && messageListData[0].messageInfo.localID) || (hasNewMessage && snapshot && Math.abs(snapshot.scrollTop) <= 1) ) { this.scrollToBottom(); } else if (hasNewMessage && messageContainer && snapshot) { const { scrollTop, scrollHeight } = messageContainer; if ( scrollHeight > snapshot.scrollHeight && scrollTop === snapshot.scrollTop ) { const newHeight = scrollHeight - snapshot.scrollHeight; const newScrollTop = Math.abs(scrollTop) + newHeight; if (supportsReverseFlex) { messageContainer.scrollTop = -1 * newScrollTop; } else { messageContainer.scrollTop = newScrollTop; } } } } scrollToBottom() { if (this.messageContainer) { this.messageContainer.scrollTop = 0; } } static keyExtractor(item: ChatMessageItem) { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } renderItem = item => { if (item.itemType === 'loader') { return (
); } const { threadInfo } = this.props; invariant(threadInfo, 'ThreadInfo should be set if messageListData is'); return ( ); }; render() { const { messageListData, threadInfo, inputState } = this.props; if (!messageListData) { return
; } invariant(inputState, 'InputState should be set'); const messages = messageListData.map(this.renderItem); let relationshipPrompt = null; if (threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ; } const messageContainerStyle = classNames({ [css.messageContainer]: true, [css.mirroredMessageContainer]: !supportsReverseFlex, }); return (
{relationshipPrompt}
{messages}
); } messageContainerRef = (messageContainer: ?HTMLDivElement) => { this.messageContainer = messageContainer; // In case we already have all the most recent messages, // but they're not enough this.possiblyLoadMoreMessages(); if (messageContainer) { messageContainer.addEventListener('scroll', this.onScroll); } }; onScroll = () => { if (!this.messageContainer) { return; } this.props.clearTooltip(); this.possiblyLoadMoreMessages(); }; possiblyLoadMoreMessages() { if (!this.messageContainer) { return; } const { scrollTop, scrollHeight, clientHeight } = this.messageContainer; if ( this.props.startReached || Math.abs(scrollTop) + clientHeight + 55 < scrollHeight ) { return; } if (this.loadingFromScroll) { return; } this.loadingFromScroll = true; const threadID = this.props.activeChatThreadID; invariant(threadID, 'should be set'); const oldestMessageServerID = this.oldestMessageServerID(); if (oldestMessageServerID) { this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } else { this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages(threadID), ); } } oldestMessageServerID(): ?string { const data = this.props.messageListData; invariant(data, 'should be set'); for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return null; } } registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); const ConnectedChatMessageList: React.ComponentType = React.memo(function ConnectedChatMessageList( props: BaseProps, ): React.Node { const { threadInfo } = props; const messageListData = useMessageListData({ threadInfo, searching: false, userInfoInputArray: [], }); const startReached = !!useSelector(state => { const activeID = threadInfo.id; if (!activeID) { return null; } if (threadIsPending(activeID)) { return true; } const threadMessageInfo = state.messageStore.threads[activeID]; if (!threadMessageInfo) { return null; } return threadMessageInfo.startReached; }); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); const inputState = React.useContext(InputStateContext); const { clearTooltip } = useTooltipContext(); - const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadInfo.id); + const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadInfo); const messageListContext = React.useMemo(() => { if (!getTextMessageMarkdownRules) { return undefined; } return { getTextMessageMarkdownRules }; }, [getTextMessageMarkdownRules]); return ( ); }); export default ConnectedChatMessageList; diff --git a/web/markdown/rules.react.js b/web/markdown/rules.react.js index 8231e986e..c4c51fcc0 100644 --- a/web/markdown/rules.react.js +++ b/web/markdown/rules.react.js @@ -1,213 +1,208 @@ // @flow import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import * as SimpleMarkdown from 'simple-markdown'; -import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors.js'; import * as SharedMarkdown from 'lib/shared/markdown.js'; -import type { RelativeMemberInfo } from 'lib/types/thread-types.js'; +import type { RelativeMemberInfo, ThreadInfo } from 'lib/types/thread-types.js'; import MarkdownSpoiler from './markdown-spoiler.react.js'; -import { useSelector } from '../redux/redux-utils.js'; export type MarkdownRules = { +simpleMarkdownRules: SharedMarkdown.ParserRules, +useDarkStyle: boolean, }; const linkRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const simpleMarkdownRules = { // We are using default simple-markdown rules // For more details, look at native/markdown/rules.react link: { ...SimpleMarkdown.defaultRules.link, match: () => null, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, paragraph: { ...SimpleMarkdown.defaultRules.paragraph, match: SimpleMarkdown.blockRegex(SharedMarkdown.paragraphRegex), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, text: SimpleMarkdown.defaultRules.text, url: { ...SimpleMarkdown.defaultRules.url, match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, }; return { simpleMarkdownRules: simpleMarkdownRules, useDarkStyle, }; }); const markdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const linkMarkdownRules = linkRules(useDarkStyle); const simpleMarkdownRules = { ...linkMarkdownRules.simpleMarkdownRules, autolink: SimpleMarkdown.defaultRules.autolink, link: { ...linkMarkdownRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SharedMarkdown.matchBlockQuote(SharedMarkdown.blockQuoteRegex), parse: SharedMarkdown.parseBlockQuote, }, spoiler: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: SimpleMarkdown.inlineRegex(SharedMarkdown.spoilerRegex), parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { const content = capture[1]; return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( ), }, inlineCode: SimpleMarkdown.defaultRules.inlineCode, em: SimpleMarkdown.defaultRules.em, strong: SimpleMarkdown.defaultRules.strong, del: SimpleMarkdown.defaultRules.del, u: SimpleMarkdown.defaultRules.u, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex(SharedMarkdown.headingRegex), }, mailto: SimpleMarkdown.defaultRules.mailto, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex(SharedMarkdown.codeBlockRegex), parse: (capture: SharedMarkdown.Capture) => ({ content: capture[0].replace(/^ {4}/gm, ''), }), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex(SharedMarkdown.fenceRegex), parse: (capture: SharedMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SharedMarkdown.Capture) => { const jsonCapture: SharedMarkdown.JSONCapture = (capture: any); return { type: 'codeBlock', content: SharedMarkdown.jsonPrint(jsonCapture), }; }, }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...linkMarkdownRules, simpleMarkdownRules, useDarkStyle, }; }); function useTextMessageRulesFunc( - threadID: ?string, -): ?(boolean) => MarkdownRules { - const threadMembers = useSelector( - relativeMemberInfoSelectorForMembersOfThread(threadID), + threadInfo: ThreadInfo, +): boolean => MarkdownRules { + const { members } = threadInfo; + return React.useMemo( + () => + _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => + textMessageRules(members, useDarkStyle), + ), + [members], ); - return React.useMemo(() => { - if (!threadMembers) { - return undefined; - } - return _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => - textMessageRules(threadMembers, useDarkStyle), - ); - }, [threadMembers]); } function textMessageRules( members: $ReadOnlyArray, useDarkStyle: boolean, ): MarkdownRules { const baseRules = markdownRules(useDarkStyle); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, mention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchMentions(members), parse: (capture: SharedMarkdown.Capture) => ({ content: capture[0], }), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => {node.content}, }, }, }; } let defaultTextMessageRules = null; function getDefaultTextMessageRules(): MarkdownRules { if (!defaultTextMessageRules) { defaultTextMessageRules = textMessageRules([], false); } return defaultTextMessageRules; } export { linkRules, useTextMessageRulesFunc, getDefaultTextMessageRules };